<?php

namespace maoxuner\LaravelJsonRpc\Server;

use Illuminate\Contracts\Routing\UrlRoutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use maoxuner\LaravelJsonRpc\Exceptions\MethodException;
use maoxuner\LaravelJsonRpc\Exceptions\ParamsException;

/**
 * Call real procedure (action)
 */
class Router
{
    protected string $namespace;

    protected string $delimiter;

    protected array $procedure = [];

    public function __construct()
    {
        $this->namespace = config('jsonrpc.namespace', 'App\\Actions');
        $this->delimiter = config('jsonrpc.delimiter', '.');
        $this->procedure = config('jsonrpc.procedure', []);
    }

    /**
     * @throws MethodException
     * @throws ParamsException
     * @throws \ReflectionException
     */
    public function dispatch(string $method, ?array $params): mixed
    {
        // Get procedure handler
        $callable = $this->getHandler($method);

        // Get handler arguments
        $arguments = $this->getArguments($callable, $params ?? []);

        // Real call procedure
        return call_user_func_array($callable, $arguments);
    }

    /**
     * @throws MethodException
     */
    protected function getHandler(string $method): callable
    {
        // Get procedure definition by method
        $procedure = array_key_exists($method, $this->procedure)
            ? $this->procedure[$method]
            : Str::of($method)
                ->explode($this->delimiter)
                ->prepend($this->namespace)
                ->implode('\\');

        if (is_callable($procedure)) {
            return $procedure;
        } elseif (empty($procedure) || ! is_string($procedure)) {
            throw new MethodException;
        }

        if (str_contains($procedure, '@')) {
            [$procedureClass, $procedureMethod] = explode('@', $procedure);
        } else {
            $procedureClass = $procedure;
            $procedureMethod = 'handle';
        }

        // Ensure class and class method exists
        if (! class_exists($procedureClass) || ! method_exists($procedureClass, $procedureMethod)) {
            throw new MethodException;
        }

        $this->procedure[$method] = $procedure = [
            app($procedureClass),
            $procedureMethod,
        ];

        return $procedure;
    }

    /**
     * @throws ParamsException
     * @throws \ReflectionException
     */
    protected function getArguments(callable $callable, array $params): array
    {
        $parameters = [];
        $urlRoutable = [];
        $paramsException = new ParamsException;

        // Extract params
        $isList = Arr::isList($params);
        $reflector = is_array($callable)
            ? new \ReflectionMethod($callable[0], $callable[1])
            : new \ReflectionFunction($callable);
        foreach ($reflector->getParameters() as $key => $parameter) {
            // Get param by name or position
            $paramName = $parameter->getName();
            $paramPosition = $parameter->getPosition();
            $index = $isList ? $paramPosition : $paramName;
            if (array_key_exists($index, $params)) {
                $value = $params[$index];
            } elseif ($parameter->isOptional()) {
                if (! $isList && $parameter->isDefaultValueAvailable()) {
                    $value = $parameter->getDefaultValue();
                } else {
                    break;
                }
            } else {
                $paramsException->merge([
                    $paramName => [
                        'Position '.$key,
                        'Missing required param',
                    ],
                ]);

                continue;
            }
            $parameters[$paramName] = $value;

            // Pickup UrlRoutable
            if (
                ($paramType = $parameter->getType()) instanceof \ReflectionNamedType
                && is_subclass_of($className = $paramType->getName(), UrlRoutable::class)
            ) {
                $urlRoutable[$paramName] = $className;
            }
        }

        if ($paramsException->isNotEmpty()) {
            throw $paramsException;
        }

        // Resolve UrlRoutable
        foreach ($urlRoutable as $paramName => $className) {
            $parameters[$paramName] = app($className)->resolveRouteBinding($parameters[$paramName]);
        }

        return array_values($parameters);
    }
}
